Skip to content

Make client-side cancellation work over the 2026 transports#3046

Draft
maxisbey wants to merge 1 commit into
mainfrom
listen-client-plumbing
Draft

Make client-side cancellation work over the 2026 transports#3046
maxisbey wants to merge 1 commit into
mainfrom
listen-client-plumbing

Conversation

@maxisbey

@maxisbey maxisbey commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Client-side request cancellation was a no-op on the 2026 wire: the modern session stamp forced cancel_on_abandon=False for every request, so abandoning a request (caller cancellation or timeout) over streamable HTTP left the POST/SSE stream open and the server running, and over 2026 stream-pair transports never sent the notifications/cancelled the spec requires. This PR makes cancellation real on both modern transports, and adds the caller-supplied request-id seam the upcoming client subscriptions/listen driver needs.

Motivation and Context

Per the 2026-07-28 spec, the two transports spell client cancellation differently:

  • Streamable HTTP: "Closing the SSE response stream is the cancellation signal. The server MUST treat a client disconnect as cancellation of that request. No notifications/cancelled message is required or expected" — and the wire defines no client-to-server notifications at all.
  • stdio / stream-pair: "The client MUST send a notifications/cancelled notification referencing the request ID."

The courtesy frame the dispatcher already emits on abandon is now the uniform internal signal: stream transports write it (spec-correct at 2026 stdio, unchanged at 2025), while the streamable-HTTP transport translates it into aborting the named request's own in-flight POST instead of writing it. Every request POST is registered with an abort scope and the era it was sent under, so a late cancel is interpreted per the named request's era rather than whatever was negotiated since; on pre-2026 wires the frame still POSTs (a 2025 disconnect is explicitly not a cancel). Registration happens synchronously in the write loop, before the POST task is spawned, so a cancel dequeued immediately after its request can never miss it, and teardown is identity-guarded so a finished task unwinding late cannot evict a reused id's successor registration. The protocol-version cache moves into the same serialized loop for the same reason: it now reflects wire order instead of POST-task scheduling order.

Separately, CallOptions["request_id"] lets a caller supply the outbound request id on both dispatchers. A subscriptions/listen client driver must know its subscription id (= the request's JSON-RPC id) before the result arrives, which the mint-internally-only design made impossible. Supplied ids reach the peer verbatim ("7" stays a string), collide loudly only for the caller who chose them (minting skips occupied keys), and both dispatchers share one coerced collision domain — so the in-memory DirectDispatcher raises exactly where JSONRPCDispatcher would in production. The negotiation methods (initialize, server/discover) keep their cancellation opt-out on every path.

How Has This Been Tested?

  • Full suite: 100.00% line+branch coverage, pyright clean.
  • New transport tests pin the translation matrix: modern abort of the matching POST; legacy frame pass-through with the stream left open; unmatched/typed-id/bool frames swallowed at modern without touching other streams; per-request era honored across a mid-session version flip; reused-id successor registration surviving a stale task's teardown.
  • An end-to-end test over a real ASGI bridge: cancelling the caller of a parked subscriptions/listen closes the POST, the server releases the subscription (observed through the bus seam), and no notifications/cancelled crosses the wire.
  • Manually against a real uvicorn server with a wire tap, via the public Client: at 2026, a parked tools/call cancelled from the caller's scope is observed as a genuine cancellation server-side with zero cancelled frames on the wire; at 2025 (mode="legacy"), the same flow POSTs exactly one notifications/cancelled frame and the stream stays open. A raw subscriptions/listen sent with request_id="verify-listen-1" came back ack-stamped with that id.

Breaking Changes

None against released surfaces. Within the v2 line: abandoning a modern-era request now produces the spec's cancellation behavior (POST abort at 2026 HTTP, a cancelled frame at 2026 stdio) instead of silently leaking the request.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

One deliberate boundary: the transport translates exactly notifications/cancelled because it is the only client-to-server notification the 2026 core protocol defines. A table-driven guard at the POST boundary (rejecting any future client notification a modern wire doesn't define) would make illegal frames unconstructible for extensions too — left as a follow-up seam rather than smuggled into this change.

The client subscriptions/listen driver (context-managed client.listen(...) yielding typed events) builds directly on these seams and follows as its own PR.

The modern session stamp suppressed the courtesy notifications/cancelled
for every request, so abandoning a request over 2026 streamable HTTP left
the POST open and the server running, and over 2026 stream-pair transports
never sent the frame the spec requires. The frame is now the dispatcher's
uniform abandon signal: stream transports write it (the 2026 stdio
cancellation spelling), while the streamable-HTTP transport translates it
into aborting the named request's own in-flight POST - closing the
response stream is that wire's cancellation signal, and no client-to-server
notification ever POSTs at 2026. Each POST records the era it was sent
under so a late cancel is interpreted per the named request, not whatever
was negotiated since; pre-2026 wires still POST the frame (a disconnect is
explicitly not a cancel there). The negotiation methods keep their
cancellation opt-out on every path.

Callers can also supply their own request id via CallOptions["request_id"]
on both dispatchers - groundwork for demultiplexing subscriptions/listen
streams, whose id must be known before the result arrives. Ids reach the
peer verbatim ("7" stays a string), collide loudly only for the caller who
chose them (minting skips occupied keys), and share one coerced collision
domain so the in-memory dispatcher raises exactly where the wire one would.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant